Project Overview
Project Goal
Build a "Users Directory" app that fetches user data from JSONPlaceholder API (https://jsonplaceholder.typicode.com/users), displays it in a list, and allows users to view details and search.
Features to Implement
- Fetch users from API on app launch
- Display users in a scrollable list
- Show user details in a detail screen
- Pull-to-refresh functionality
- Search/filter users by name
- Loading and error states
Project Setup
Step 1: Create Project
flutter create users_directory
cd users_directory
Step 2: Add Dependencies
Update pubspec.yaml:
pubspec.yaml
name: users_directory
description: A Flutter app that fetches and displays users from API
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
cupertino_icons: ^1.0.6
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
Step 3: Install Dependencies
flutter pub get
Project Structure
Suggested Folder Layout
lib/
main.dart
models/
user.dart
address.dart
company.dart
services/
api_service.dart
screens/
home_screen.dart
user_detail_screen.dart
widgets/
user_card.dart
loading_widget.dart
error_widget.dart
utils/
constants.dart
Creating Data Models
User Model
Create lib/models/user.dart:
lib/models/user.dart
class User {
final int id;
final String name;
final String username;
final String email;
final String phone;
final String website;
final Address address;
final Company company;
User({
required this.id,
required this.name,
required this.username,
required this.email,
required this.phone,
required this.website,
required this.address,
required this.company,
});
factory User.fromJson(Map json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
username: json['username'] as String,
email: json['email'] as String,
phone: json['phone'] as String,
website: json['website'] as String,
address: Address.fromJson(json['address'] as Map),
company: Company.fromJson(json['company'] as Map),
);
}
Map toJson() {
return {
'id': id,
'name': name,
'username': username,
'email': email,
'phone': phone,
'website': website,
'address': address.toJson(),
'company': company.toJson(),
};
}
}
class Address {
final String street;
final String suite;
final String city;
final String zipcode;
final Geo geo;
Address({
required this.street,
required this.suite,
required this.city,
required this.zipcode,
required this.geo,
});
factory Address.fromJson(Map json) {
return Address(
street: json['street'] as String,
suite: json['suite'] as String,
city: json['city'] as String,
zipcode: json['zipcode'] as String,
geo: Geo.fromJson(json['geo'] as Map),
);
}
Map toJson() {
return {
'street': street,
'suite': suite,
'city': city,
'zipcode': zipcode,
'geo': geo.toJson(),
};
}
String get fullAddress => '$street, $suite, $city $zipcode';
}
class Geo {
final String lat;
final String lng;
Geo({required this.lat, required this.lng});
factory Geo.fromJson(Map json) {
return Geo(
lat: json['lat'] as String,
lng: json['lng'] as String,
);
}
Map toJson() {
return {
'lat': lat,
'lng': lng,
};
}
}
class Company {
final String name;
final String catchPhrase;
final String bs;
Company({
required this.name,
required this.catchPhrase,
required this.bs,
});
factory Company.fromJson(Map json) {
return Company(
name: json['name'] as String,
catchPhrase: json['catchPhrase'] as String,
bs: json['bs'] as String,
);
}
Map toJson() {
return {
'name': name,
'catchPhrase': catchPhrase,
'bs': bs,
};
}
}
Creating API Service
API Service Class
Create lib/services/api_service.dart:
lib/services/api_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/user.dart';
class ApiService {
static const String baseUrl = 'https://jsonplaceholder.typicode.com';
Future> fetchUsers() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/users'),
).timeout(Duration(seconds: 10));
if (response.statusCode == 200) {
final List jsonList = jsonDecode(response.body);
return jsonList.map((json) => User.fromJson(json)).toList();
} else {
throw Exception('Failed to load users: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching users: $e');
}
}
Future fetchUserById(int id) async {
try {
final response = await http.get(
Uri.parse('$baseUrl/users/$id'),
).timeout(Duration(seconds: 10));
if (response.statusCode == 200) {
final json = jsonDecode(response.body);
return User.fromJson(json);
} else {
throw Exception('Failed to load user: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching user: $e');
}
}
}
Creating Constants
lib/utils/constants.dart
class AppConstants {
static const String appName = 'Users Directory';
static const String apiBaseUrl = 'https://jsonplaceholder.typicode.com';
}
Creating Reusable Widgets
Loading Widget
Create lib/widgets/loading_widget.dart:
lib/widgets/loading_widget.dart
import 'package:flutter/material.dart';
class LoadingWidget extends StatelessWidget {
const LoadingWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
'Loading users...',
style: TextStyle(fontSize: 16),
),
],
),
);
}
}
Error Widget
Create lib/widgets/error_widget.dart:
lib/widgets/error_widget.dart
import 'package:flutter/material.dart';
class ErrorDisplayWidget extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const ErrorDisplayWidget({
Key? key,
required this.message,
this.onRetry,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
SizedBox(height: 16),
Text(
'Error',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14),
),
if (onRetry != null) ...[
SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onRetry,
icon: Icon(Icons.refresh),
label: Text('Retry'),
),
],
],
),
),
);
}
}
User Card Widget
Create lib/widgets/user_card.dart:
lib/widgets/user_card.dart
import 'package:flutter/material.dart';
import '../models/user.dart';
class UserCard extends StatelessWidget {
final User user;
final VoidCallback onTap;
const UserCard({
Key? key,
required this.user,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
user.name[0].toUpperCase(),
style: TextStyle(color: Colors.white),
),
),
title: Text(
user.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4),
Text(user.email),
Text(user.phone),
],
),
trailing: Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
Creating Home Screen
lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import '../models/user.dart';
import '../services/api_service.dart';
import '../widgets/user_card.dart';
import '../widgets/loading_widget.dart';
import '../widgets/error_widget.dart';
import 'user_detail_screen.dart';
class HomeScreen extends StatefulWidget {
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State {
final ApiService _apiService = ApiService();
List _users = [];
List _filteredUsers = [];
bool _isLoading = false;
String? _error;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadUsers();
_searchController.addListener(_filterUsers);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future _loadUsers() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final users = await _apiService.fetchUsers();
setState(() {
_users = users;
_filteredUsers = users;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
void _filterUsers() {
final query = _searchController.text.toLowerCase();
setState(() {
if (query.isEmpty) {
_filteredUsers = _users;
} else {
_filteredUsers = _users.where((user) {
return user.name.toLowerCase().contains(query) ||
user.email.toLowerCase().contains(query);
}).toList();
}
});
}
void _navigateToDetail(User user) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserDetailScreen(user: user),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Users Directory'),
actions: [
if (!_isLoading && _error == null)
IconButton(
icon: Icon(Icons.refresh),
onPressed: _loadUsers,
tooltip: 'Refresh',
),
],
),
body: Column(
children: [
// Search bar
if (!_isLoading && _error == null)
Padding(
padding: EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search users by name or email...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
// Content
Expanded(
child: _buildBody(),
),
],
),
);
}
Widget _buildBody() {
if (_isLoading) {
return LoadingWidget();
}
if (_error != null) {
return ErrorDisplayWidget(
message: _error!,
onRetry: _loadUsers,
);
}
if (_filteredUsers.isEmpty) {
return Center(
child: Text(
_searchController.text.isEmpty
? 'No users found'
: 'No users match your search',
style: TextStyle(fontSize: 16),
),
);
}
return RefreshIndicator(
onRefresh: _loadUsers,
child: ListView.builder(
itemCount: _filteredUsers.length,
itemBuilder: (context, index) {
final user = _filteredUsers[index];
return UserCard(
user: user,
onTap: () => _navigateToDetail(user),
);
},
),
);
}
}
Creating User Detail Screen
lib/screens/user_detail_screen.dart
import 'package:flutter/material.dart';
import '../models/user.dart';
class UserDetailScreen extends StatelessWidget {
final User user;
const UserDetailScreen({Key? key, required this.user}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(user.name),
),
body: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSection(
'Personal Information',
[
_buildInfoRow('Name', user.name),
_buildInfoRow('Username', user.username),
_buildInfoRow('Email', user.email),
_buildInfoRow('Phone', user.phone),
_buildInfoRow('Website', user.website),
],
),
SizedBox(height: 24),
_buildSection(
'Address',
[
_buildInfoRow('Street', user.address.street),
_buildInfoRow('Suite', user.address.suite),
_buildInfoRow('City', user.address.city),
_buildInfoRow('Zipcode', user.address.zipcode),
_buildInfoRow('Full Address', user.address.fullAddress),
_buildInfoRow('Coordinates', '${user.address.geo.lat}, ${user.address.geo.lng}'),
],
),
SizedBox(height: 24),
_buildSection(
'Company',
[
_buildInfoRow('Name', user.company.name),
_buildInfoRow('Catch Phrase', user.company.catchPhrase),
_buildInfoRow('Business', user.company.bs),
],
),
],
),
),
);
}
Widget _buildSection(String title, List children) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 12),
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: children,
),
),
),
],
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
'$label:',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(fontSize: 14),
),
),
],
),
);
}
}
Updating Main.dart
lib/main.dart
import 'package:flutter/material.dart';
import 'screens/home_screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Users Directory',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: HomeScreen(),
);
}
}
Running the App
Commands
# Run the app
flutter run
# Run in release mode
flutter run --release
# Build APK
flutter build apk
Project Checklist
Implementation Checklist
- ✅ Project created and dependencies added
- ✅ Folder structure organized
- ✅ User model with nested Address and Company models
- ✅ API service with error handling
- ✅ Loading widget for async states
- ✅ Error widget with retry functionality
- ✅ User card widget for list items
- ✅ Home screen with search and pull-to-refresh
- ✅ User detail screen with complete information
- ✅ Main.dart configured
Enhancement Ideas
Optional Enhancements
- Add local caching using SharedPreferences
- Implement pagination for large datasets
- Add favorite users functionality
- Implement dark mode support
- Add user avatar images
- Create a map view showing user locations
- Add sorting options (by name, email, etc.)
Exercises
1. Complete Implementation
Implement the entire project following the structure and code provided. Test all features including search, pull-to-refresh, and navigation to detail screen.
2. Add Caching
Implement local caching using SharedPreferences. Save fetched users locally and load them on app start while fetching fresh data in the background.
3. Add Favorites
Add a favorite button to each user card. Store favorite user IDs locally and show a separate favorites screen with only favorited users.